简介
A/B系统就是指手机上有A,B两套可用的系统(userdata只有一份)。这两份系统同一时间只运行一个,另外一个作为备份。这两个系统的版本可能一样,也可能不一样。
这里称为一套(slot)系统,是因为Android系统由多个分区组成。对于A/B,分为system_a,vendor_a,boot_a和system_b,vendor_b,boot_b。所以一套就是一组完整的分区。
优势
- 由于手机上有两套可以正常工作的系统,升级系统时总是存在一个可以运行的系统,减少手机变砖的可能性
- OTA在系统后台进行,所以更新时用户可以正常使用设备,升级完成后只需要重启一次进入新系统即可
- 更新后重启系统的时间不会超过常规重启时间(传统OTA需要在recovery中升级,重启过程比较慢)
- 如果更新的系统无法启动,设备将重启回到旧系统,然后尝试再次更新
- 任何错误(例如 I/O 错误)都只会影响未使用的分区组,并且用户可以进行重试。由于 I/O 负载被特意控制在较低水平,以免影响用户体验,因此发生此类错误的可能性也会降低
- 更新包可以流式传输到 A/B 设备,因此在安装之前不需要先下载更新包。流式更新意味着用户没有必要在
/data
或/cache
上留出足够的可用空间来存储更新包 - cache分区不再用于存储 OTA 更新包,因此无需确保缓存分区的大小要足以应对日后的更新
- dm-verity 可保证设备将使用未损坏的启动映像。如果设备因 OTA 错误或 dm-verity 问题而无法启动,则可以重新启动到旧img
与传统OTA差别
- 系统的分区设置
- 传统方式只有一套分区
A/B
系统有两套分区,称为slot A
和slot B
- 跟bootloader沟通的方式
- 传统方式bootloader通过读取
misc
分区信息来决定是进入Android主系统还是Recovery系统 A/B
系统的bootloader通过特定的分区信息来决定从slot A
还是slot B
启动
- 传统方式bootloader通过读取
- 系统的编译过程
- 传统方式在编译时会生成
boot.img
和recovery.img
分别用于Android主系统和Recovery系统的ramdisk A/B
系统只有boot.img
,而不再生成单独的recovery.img
- 传统方式在编译时会生成
- OTA更新包的生成方式
A/B
系统生成OTA包的工具和命令跟传统方式一样,但是生成内容的格式不一样了
分区具体差别:
传统分区
bootloader
存放用于引导linux的bootloader
boot
存放Android主系统linux kernel文件和用于挂载system和其他分区的ramdisk
system
Android 主系统分区,包括Android 系统App和库文件
vendor
Android主系统分区,主要是包含开发厂商定制的一些应用和库文件。tremble了解一下
userdata
用户数据分区,存放用户数据,包括用户安装的App和使用时生成的数据
cache
临时数据分区,通常存放OTA升级包
recovery
存放recovery系统的linux kernel文件和ramdisk
misc
存放Android主系统和Recovery系统跟bootloader通信的数据
A/B system分区
bootloader
存放用于引导linux的bootloader
boot_a和boot_b
分别用于存放两套系统各自的linux kernel文件和用于挂载system和其他分区的ramdisk
system_a和system_b
Android主系统分区,分别用于存放两套系统各自的系统App和库文件
vendor_a和vendor_b
Android主系统分区,分别用于存放两套系统各自开发厂商定制的一些应用和库文件,很多时候开发厂商也直接将这个分区的内容直接放入system分区
userdata
用户数据分区,存放用户数据,包括用户安装的App和使用时生成的数据
misc或其他名字分区
存放Android主系统和Recovery系统跟bootloader通信的数据,由于存放方式和分区名字没有强制要求,所以部分实现上保留了
misc
分区(代码中可见Brillo
和Intel
的平台),另外部分实现采用其他分区存放数据(Broadcom
机顶盒平台采用名为eio
的分区)。
区别:
- boot, system, vendor变成两套分区,slot A和slot B
- 不再需要cache和recovery分区
- misc分区不是必须的
更新流程
可以参考代码 boot_control.h
- 通过
markBootSuccessful()
将当前slot(或“源slot”)标记为成功(如果尚未标记) - 调用函数
setSlotAsUnbootable()
,将未使用的slot(或“目标slot”)标记为不可启动。当前slot始终会在更新开始时被标记为成功,以防止引导加载程序回退到未使用的slot(该slot中很快将会有无效数据)。如果系统已做好准备,可以开始应用更新,那么即使其他主要组件出现损坏(例如界面陷入崩溃循环),当前slot也会被标记为成功,因为可以通过推送新软件来解决这些问题- 元数据。元数据在更新有效负载中所占的比重相对较小,其中包含一系列用于在目标slot上生成和验证新版本的操作。例如,某项操作可能会解压缩特定 Blob 并将其写入到目标分区中的特定块,或者从源分区读取数据、应用二进制补丁程序,然后写入到目标分区中的特定块
- 额外数据。与操作相关的额外数据在更新有效负载中占据了大部分比重,其中包含这些示例中的已压缩 Blob 或二进制补丁程序
- 下载有效负载元数据
- 对于元数据中定义的每项操作,都将按顺序发生以下行为:将相关数据(如果有)下载到内存中、应用操作,然后释放关联的内存
- 对照预期的哈希重新读取并验证所有分区
- 运行安装后步骤(如果有)。如果在执行任何步骤期间出现错误,则更新失败,系统可能会通过其他有效负载重新尝试更新。如果上述所有步骤均已成功完成,则更新成功,系统会执行最后一个步骤
- 调用
setActiveBootSlot()
,将未使用的槽位标记为活动槽位。将未使用的槽位标记为活动槽位并不意味着它将完成启动。如果引导加载程序(或系统本身)未读取到“成功”状态,则可以将活动槽位切换回来 - 安装后步骤(如下所述)包括从“新更新”版本中运行仍在旧版本中运行的程序。如果此步骤已在 OTA 更新包中定义,则为强制性步骤,且程序必须返回并显示退出代码
0
,否则更新会失败 - 在系统足够深入地成功启动到新槽位并完成重新启动后检查之后,系统会调用
markBootSuccessful()
,将现在的当前槽位(原“目标槽位”)标记为成功
用具体的示例介绍流程:
如图,每一个slot都有3个属性:
active
系统活动分区标识。同时只有一个slot是active状态,启动时就会启动标识为此的slot
bootable
分区可启动标识。有此标识说明该分区包含一个完整可启动的系统
successful
分区运行成功标识。说明该分区在上次或者当前启动可以正常运行
图中4个场景如下
普通场景(
Normal cases
)最常见的情形,例如设备出厂时,A分区和B分区都可以成功启动并正确运行,所以两个分区都设置为
bootable
和successful
,但由于是从B分区启动,所以只有B分区设置为active
。升级中(
Update in progress
)B分区检测到升级数据,在A分区进行升级,此时将A分区标识为
unbootable
,另外清除successful
标识;B分区仍然为active
,bootable
和successful
。更新完成,等待重启(
Update applied, reboot pending
)B分区将A分区成功更新后,将A分区标识为
bootable
。另外,由于重启后需要从A分区启动,所以也需要将A分区设置为active
,但是由于还没有验证过A分区是否能成功运行,所以不设置successful
;B分区的状态变为bootable
和successful
,但没有active
。从新系统成功启动(
System rebooted into new update
)设备重启后,
bootloader
检测到A分区为active
,所以加载A分区系统。进入A系统后如果能正确运行,需要将A分区标识为successful
。对比第1个普通场景,A和B系统都设置为bootable
和successful
,但active
从B分区切换到A分区。至此,B分区成功更新并切换到A分区,设备重新进入普通场景。
分区烧写
首先A/B系统和传统OTA系统只能存在一个,在编译时选择。烧写A/B系统也和烧正常系统一样,指定ab分区即可。
target reported max download size of 536870912 bytes
erasing 'system_a'...
OKAY [ 0.023s]
sending sparse 'system_a' 1/7 (516186 KB)...
OKAY [ 17.114s]
writing 'system_a' 1/7...
target reported max download size of 536870912 bytes
sending sparse 'system_b' 1/7 (516186 KB)...
OKAY [ 16.368s]
writing 'system_b' 1/7...
Java接口使用方法
Android提供了Java层的接口,UpdateEngine.java
接口很好用
- 首先创建一个UpdateEngine的实例
- 实现 UpdateEngineCallback 回调接口
- 调用bind方法绑定Callback
- 调用applyPayload方法执行更新
@SystemApi
public void applyPayload(String url, long offset, long size, String[] headerKeyValuePairs) {
try {
mUpdateEngine.applyPayload(url, offset, size, headerKeyValuePairs);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
这个方法是@SystemApi,调用它有两个方法:
- 把APK放入源码环境,写Makefile进行编译
- 独立APK,把系统framework.jar作为lib导入APK。(也可以只把UpdateEngine相关类打包编译)
关于参数,第一个是差分包(zip包)的路径。第四个是差分包内部的一个文件,这个文件由做包工具生成,一般会保存为一个payload_properties.txt文件,文件大概内容如下:
FILE_HASH=lURPCIkIAjtMOyB/EjQcl8zDzqtD6Ta3tJef6G/+z2k=
FILE_SIZE=871903868
METADATA_HASH=tBvj43QOB0Jn++JojcpVdbRLz0qdAuL+uTkSy7hokaw=
METADATA_SIZE=70604
我们要做的就是把文件内容读出来,存在数组里作为第四个参数,形式如下。
String[] pairs = {
"FILE_HASH=lURPCIkIAjtMOyB/EjQcl8zDzqtD6Ta3tJef6G/+z2k=",
"FILE_SIZE=871903868",
"METADATA_HASH=tBvj43QOB0Jn++JojcpVdbRLz0qdAuL+uTkSy7hokaw=",
"METADATA_SIZE=70604"
};
然后贴一个我用的处理方法:
private String[] readHeaderKeyValuesFromZipFile(String file) {
String[] values = new String[4];
try {
ZipFile zf = new ZipFile(file);
InputStream in = new BufferedInputStream(new FileInputStream(file));
ZipInputStream zin = new ZipInputStream(in);
ZipEntry ze;
while ((ze = zin.getNextEntry()) != null) {
if (ze.isDirectory()) {
//Do nothing
} else {
if (ze.getName().contains("payload_properties.txt")) {
BufferedReader br = new BufferedReader(
new InputStreamReader(zf.getInputStream(ze)));
String line;
int index = 0;
while ((line = br.readLine()) != null) {
values[index] = line;
index++;
}
br.close();
}
}
}
zin.closeEntry();
} catch (IOException e) {
e.printStackTrace();
}
return values;
}